feat(connector): Finix Apple Pay Tokenize + SetupRecurring, Apple Pay & Google Pay webhooks - #2034
Open
shuklatushar226 wants to merge 6 commits into
Open
feat(connector): Finix Apple Pay Tokenize + SetupRecurring, Apple Pay & Google Pay webhooks#2034shuklatushar226 wants to merge 6 commits into
shuklatushar226 wants to merge 6 commits into
Conversation
…etupMandate flows Finix exchanges wallet device tokens for a Payment Instrument via POST /payment_instruments before the wallet can be charged. Extend the existing Google Pay tokenization path to also cover Apple Pay: - `should_do_payment_method_token` now returns true for `PaymentMethodType::ApplePay`, so the Tokenize flow is invoked. - Add `FinixApplePay*` types modelling the PassKit payment token (paymentData, paymentMethod, transactionIdentifier); all cryptogram material is held in `Secret` from construction. - Add `build_apple_pay_instrument_request`, which base64-decodes the PassKit token and re-serializes it as the JSON string Finix expects in `third_party_token`, with `merchant_identity` taken from the connector auth. Shared by Tokenize and SetupMandate so the two paths cannot drift. Validation: cargo build clean; card Tokenize regression passes. The Apple Pay grpcurl call returns Finix 422 "Invalid Apple Pay token" because Finix cryptographically verifies the Apple-issued PassKit signature server-side and no Finix sandbox Apple Pay token exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oogle Pay name/address, real disabled-instrument errors This extends commit 44b89f1 (ApplePay Tokenize + SetupMandate arm), it does not replace it. Fix 1: card `expiration_year` was sent verbatim, so a 2-digit year like "30" was stored as year 30; now routed through the shared 4-digit expansion util. New helper `get_finix_expiration_year()`; call sites in Tokenize and SetupMandate. Fix 2: Google Pay instrument request dropped billing `name` and `address` (Apple Pay arm already sent them); factored into `build_google_pay_instrument_request` mirroring the Apple Pay helper, and its parse error now names Google Pay's own token fields. Fix 3: `disabled_instrument_error` (now `instrument_not_usable_error`) collapsed `enabled: false` and a missing `id` into NO_ERROR_CODE/NO_ERROR_MESSAGE; each branch now returns a real connector code, message and reason. Applied to both Tokenize and SetupMandate response transformers. Validation: cargo build clean, clippy clean, cargo fmt clean. Live-verified against Finix sandbox: SetupRecurring(Card) -> PIi1XiiNbq2A3xF3UYrXfJrT / CHARGED with mandateReference populated, expiry "30" stored back as 2030; RecurringPaymentService/Charge MIT against that mandate -> TRpxD1Z7dFpJtKkKBFbJ2GBo / CHARGED; Tokenize(Card) regression -> PIdrr9t8dBcM3Kn5vRFA9WUH, expiry "31" stored as 2031. Apple Pay and Google Pay SetupRecurring are structurally verified only: Finix returns 422 INVALID_FIELD "Invalid Apple Pay token" / "Invalid Google Pay token" because it verifies the wallet-issued signature server-side and no sandbox wallet token exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… fix dispute/tags decoding, stop mis-classifying ledger transfers
Brings the Finix `IncomingWebhook` implementation to parity with the Direct
gateway and fixes four defects that made real Finix deliveries either fail to
decode or be reported as the wrong resource.
Status mapping is now canonical
* `FinixState` (webhook-only) is deleted; `FinixPaymentStatus` is the single
deserialization source of truth for the Finix `state` field. It gains
`#[serde(other)]` (an unmapped state no longer fails the whole body) and an
explicit `RETURNED` variant, which is documented on `GET /transfers/{id}`.
* One `get_finix_attempt_status(state, flow, is_void)` and one
`From<&FinixPaymentStatus> for RefundStatus` now serve Authorize, PSync,
Capture, Void, Refund, RSync, RepeatPayment and the webhook path, so a
webhook can no longer report a different status than a sync for the same
connector state. `UNKNOWN` stays `Pending` everywhere instead of being a
terminal failure on the webhook path only.
Body decoding
* `FinixDisputes.currency` is now `Option<Currency>`: Finix's Dispute resource
has no `currency` key, so the previous required field failed decoding for
every real dispute event and took `get_event_type` and
`get_webhook_event_reference` down with it. The absence is reported as
`WebhookMissingRequiredField { field: "currency" }` from the dispute builder
only, mirroring the Direct gateway.
* `tags` is now `Option<FinixTags>` (as in Direct); an authorization event that
omits it previously failed to decode.
* `FinixDisputeState` gains `#[serde(other)]`; `FinixEmbedded` gains the
`Evidences` variant so the default-on `evidence.created` subscription
degrades to "event not supported" rather than a decode failure.
Event classification and references
* Transfer handling is matched per `type` again instead of collapsing to
"REVERSAL vs everything else". CREDIT / FEE / ADJUSTMENT / DISPUTE / RESERVE
/ SETTLEMENT and an absent `type` are platform-ledger movements: they now
yield no event and `WebhookEventTypeNotFound` instead of being reported as a
successful payment against the transfer id.
* The payment and refund builders reject any resource that is not an
authorization / DEBIT transfer / REVERSAL transfer, so the shared
dispatcher's fallback to the payment builder cannot mis-report them.
Source verification
* The signed message appends the raw body byte-for-byte instead of going
through `String::from_utf8_lossy`, which would substitute U+FFFD before
hashing and turn a valid signature into a mismatch.
Verified live against `EventService/ParseEvent` and `EventService/HandleEvent`
with Finix-shaped payloads: payment success/failure, refund success/failure,
dispute with and without `currency`, void, RETURNED, unknown state, settlement
transfer, evidence entity, tags omitted, endpoint-verification probe, and
signature failures (wrong key, missing header, missing sig, non-hex sig, no
secret).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ay webhook verification
Google Pay scope: Finix emits no wallet-specific webhook. Its webhook event
catalogue has no Google Pay entity or event type, and the `transfer`,
`authorization` and `dispute` resources carry no instrument-type
discriminator — Finix's own Google Pay guide shows a Google-Pay-originated
`POST /transfers` response that is field-for-field identical to a card
transfer, its only wallet trace being `source` pointing at a `PI…` id. The
sole place `GOOGLE_PAY` appears on the webhook surface is `instrument.type`
on the `instrument` entity, which this connector deliberately does not
model. Google Pay therefore needs no wallet-specific webhook code, and none
is added here.
Verifying that against real payloads did surface one decoding defect:
* `FinixDisputes.amount` was a required `MinorUnit`, but Finix declares
`Dispute.amount` as `nullable: true` (OpenAPI
`components.schemas.dispute.properties.amount`) and marks no Dispute field
as required. A dispute delivered with `"amount": null` failed
`FinixWebhookBody` deserialization outright and — because `FinixEmbedded`
is untagged — took `get_event_type` and `get_webhook_event_reference` down
with it, so the event surfaced as an undiagnosable
`WebhookBodyDecodingFailed` with nothing pointing at the cause. It is now
`Option<MinorUnit>`; the absence is reported as
`WebhookMissingRequiredField { field: "amount" }` from
`build_finix_dispute_webhook_response` only, exactly as the missing
`currency` key already is, and no zero amount is fabricated.
Verified live against `EventService/ParseEvent` and `EventService/HandleEvent`
over gRPC with real HMAC-SHA256 `Finix-Signature` headers, using 21
Google-Pay-originated payloads built from Finix's documented webhook samples:
sale SUCCEEDED/PENDING/FAILED, authorization success and void, refund
success/failure, dispute with and without `currency`, dispute with a null
amount, RETURNED, unknown state, settlement transfer, `instrument.created`
for a GOOGLE_PAY instrument, optional fields omitted, the documented
"Authorization Captured" sample, wrong signing key, and Finix's pretty-printed
wire format signed over the exact bytes versus over re-serialized JSON.
Before this change the null-amount dispute returned "Failed to decode webhook
event body" from both entry points; it now parses to `WEBHOOK_DISPUTE_OPENED`
with the correct transfer reference and reports "Missing required field
'amount'" from HandleEvent alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… webhook event type with payload status Remediation of the independent parity audit of 44b89f1..6c8aae1. F-2 (regression): the `get_finix_attempt_status()` unification applied `CANCELED -> Voided` unconditionally, so a 201 `{"state":"CANCELED", "failure_code":"card_expired"}` on Authorize/Capture/PSync/RepeatPayment stopped being a payment failure. `is_payment_failure(Voided)` is false, so the transformer returned `Ok(TransactionResponse)` instead of `Err(ErrorResponse{code:"card_expired"})` and dropped the decline reason. Move `CANCELED -> Voided` inside the `is_void == Some(true)` branch; outside a void it joins FAILED/RETURNED. `UNKNOWN -> Pending` and the PENDING-void -> Pending correction are kept, and Void/Refund/RSync stay byte-identical to 45351c2. F-1 (regression): `get_finix_webhook_event_type` was left on the old grouping while the content status mapping was unified, so a successful void (is_void:true, CANCELED) emitted `PaymentIntentCancelFailure` carrying a `Voided` payload. The payment arms now derive their event type from `get_finix_attempt_status` via `get_finix_webhook_payment_event_type`, which makes agreement structural. DEBIT transfers now honour `is_void` for the same reason. `UNKNOWN` keeps emitting no event (a non-assertion, so it cannot contradict). F-3: `FinixWebhookPaymentsResponse.amount`/`.currency` are required but never read, and `FinixEmbedded` is `#[serde(untagged)]`, so one missing key collapsed the whole body decode and took get_event_type, get_webhook_event_reference and get_webhook_resource_object down with it. Now `Option<T>` + `#[serde(default)]`, matching what was already done for `tags`. Same treatment for the equally unread `FinixAuthorizeResponse.amount`/`.currency`. F-9: `get_finix_expiration_year("203")` returned `Ok(203)` and sent `expiration_year: 203`; `get_expiry_year_4_digit()` only expands two-digit input. Non-four-digit years are now rejected with the existing typed `InvalidDataFormat`. F-8: delete the dead `impl From<&FinixPaymentStatus> for AttemptStatus`, which hardcoded `FinixFlow::Transfer` and would have silently produced `Charged` in an authorization context. F-4 (NOT fixed — needs a core change): `process_dispute_webhook` hard-errors on `dispute.currency`, which the Finix `dispute` schema does not define, so a merchant is never notified of a chargeback. It cannot be fixed in connector scope: `process_dispute_webhook` takes no `EventContext`, `EventContext` carries only `capture_method`, and the proto `DisputeEventContext` is empty. Fabricating a currency is not acceptable, so the failure is documented in place and the error switched to `WebhookMissingRequiredContext`, which names the actual gap. Adds the first finix tests: a table-driven (flow, state, is_void) -> AttemptStatus matrix, an event-type/status agreement property test, and expiry-year boundaries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds 36 verified Finix documentation URLs (webhooks, wallets, payment instruments, recurring) discovered during the ApplePay/GooglePay flow work. Additive only; no existing entries modified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
[should-fix] Dispute webhook handling now requires |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements four Finix wallet flows in UCS, with a full logic-parity pass against the reference implementation in
hyperswitch/crates/hyperswitch_connectors/src/connectors/finix:PaymentMethodToken)SetupMandate)Flow-name mapping for reviewers:
Tokenize/SetupRecurringare the UCS gRPC rpc names; the legacy Hyperswitch flow types arePaymentMethodToken/SetupMandate. Parity was assessed against the latter.The work was implemented flow-by-flow, then reviewed by an independent adversarial audit that validated claims against the real Finix OpenAPI (
https://docs.finix.com/_spec/api/index.yaml) rather than only against the reference implementation. The audit found two regressions introduced by the first four commits; both are fixed in5fdd260dd, which also adds a status-matrix test to pin the behaviour.Commits
44b89f158build_apple_pay_instrument_request(), Tokenize + SetupMandate arms4f59b3895f809ad8906c8aae17c5fdd260ddCANCELEDfailure semantics, reconcile webhook event type with payload status158f877c2Defects fixed along the way
These were found while implementing the four flows and are fixed here:
SETTLEMENTtransfer returnedPAYMENT_INTENT_SUCCESSwith a payment reference — a merchant payout would have been applied to a payment attempt as a charge. NowWebhookEventTypeNotFound.currencywas required but Finix omits it; becauseFinixEmbeddedis#[serde(untagged)], the failure took downget_event_typeandget_webhook_event_referencetoo.FinixDisputes.amountwas required but Finix declares itnullable: true— same untagged-decode collapse.FinixPaymentStatushad no#[serde(other)], so aRETURNEDstate broke PSync/Refund deserialization outright.expiration_yearwas sent verbatim — a merchant sending"30"stored a card-on-file mandate withexpiration_year: 30. Now expanded via the existing shared util. Verified live: sent"30", read back2030.name/addresswhile the Apple Pay arm sent them.disabled_instrument_errorcollapsed two distinct failures intoNO_ERROR_CODE/NO_ERROR_MESSAGE.String::from_utf8_lossy— U+FFFD substitution would turn a valid signature into a mismatch. Now byte-exact, which Finix's docs require.Structural cleanup
Deleted the webhook-only
FinixStateenum soFinixPaymentStatusis the single deserialization source for Finixstate, and routed all 8 flows (Authorize, PSync, Capture, RSync, Void, Refund, RepeatPayment, webhooks) through one canonicalget_finix_attempt_status(), deleting 8 hand-rolled inline matches. The webhook event-type mapper is now defined in terms of the status mapper, so the two cannot drift apart by hand.Testing
EventService/ParseEvent+/HandleEventwith real HMAC-SHA256Finix-Signatureheaders. The same suite scores 18-passed / 14-failed against the pre-remediation binary; the 14 are exactly the regression rows.4000000000009987→ 402 →AUTHORIZATION_FAILED). Byte-identical before and after remediation.SetupRecurring→ 201CHARGEDwithmandateReference, then an MITRecurringPaymentService/Chargeagainst that mandate → 201.finix/test.rspins a 54-triple(flow, state, is_void) → AttemptStatusmatrix, pluscanceled_without_void_is_a_payment_failure,unknown_is_never_terminal,pending_void_stays_pending.cargo build,cargo clippy --all-targets,cargo +nightly fmt --check, and 7 unit tests all clean.Known testing limitation
No wallet-token-bearing call can return 2xx in this environment. Finix cryptographically verifies the Apple-issued PassKit signature server-side and publishes no sandbox wallet token; a structurally-correct request returns
422 INVALID_FIELD "Invalid Apple Pay token"while the identical Card request succeeds. Apple Pay / Google Pay request construction is therefore verified structurally, not live — request JSON shape, error surfacing with real code/message/reason, and fast-failing negative paths. Webhook flows are unaffected and fully live-tested, since they are payload-driven.Both wallet token shapes were verified against the vendor OpenAPI examples: Apple Pay is the wrapped
{"token":{...}}object serialized to a string, Google Pay is the raw ECv2 token string with no wrapper.Intentional deviations from the reference
Every deviation below is deliberate and was reviewed by the independent audit.
UNKNOWN→Pending(reference: terminal failure)PENDINGvoid staysPending(reference:Voided)CANCELED→Voidedonly whenis_voidis set5fdd260dd. On non-void paths there is no void in flight, and collapsing both contexts discards the decline reason.RETURNEDmodelled explicitly (reference has it commented out)transfer.state; leaving it unmodelled broke PSync deserialization.from_utf8_lossySecret<String>(reference: plainString)is_void = Some(true)SUCCEEDED → Chargedon a void.tags{merchant_reference}(reference:tags: None)POST /payment_instrumentshas noidempotency_id; the tag is the only correlation handle.Charged)NetworkMandateId/NetworkTokenWithNTInetwork_transaction_idproperty at all; it chains by its own transfer id.EventNotSupported→IncomingWebhookEventUnspecifiedconnector_dispute_idstaysNoneon the dispute referencedispute.transfer, matching the reference'sPaymentId(ConnectorTransactionId(..)).transferschema has 47 properties with no wallet discriminator.Known issues NOT fixed here
Deliberately out of scope. Each is pre-existing and/or needs a change outside
connectors/finix*.credential_on_fileis never sent on RepeatPayment (CRITICAL). The code asserts "No MIT-specific fields are required"; the Finix OpenAPI contradicts this —POST /transfersand/authorizationsacceptcredential_on_file{type, initial_transfer_id}, withinitial_transfer_idrequired for bothRECURRINGandUNSCHEDULED. Month-2 subscription charges therefore post as ordinary CNP transactions, risking soft declines, loss of recurring interchange tier, and Visa Stored Credential Framework / Mastercard MIT assessments.hyperswitchDirect has the identical gap, so fixing it here would deliberately break parity — it needs a coordinated change in both, and should be raised against Direct too. The plumbing already exists unused:RepeatPaymentData.mit_categorymaps 1:1 onto Finix's enum and is already consumed by shift4/checkout/dlocal/tsys_transit.process_dispute_webhookneeds a currency that does not exist in Finix's 19-property dispute schema. It is not fixable in connector scope: domainEventContextcarries onlycapture_method, the protoDisputeEventContextis an empty message, anddispute.transferwould need an outboundGET. Related finding:ForeignTryFrom<DisputeWebhookDetailsResponse> for DisputeResponsehardcodesdispute_amount: None, so every connector currently computes a dispute amount/currency that no caller consumes. Rather than fabricate a value, the error now names the real gap (WebhookMissingRequiredContext) instead of falsely claiming Finix omitted a field it does not define.HandleEvent.webhook_utils.rs:72-81routes any non-payment/refund/dispute event to the payment builder, which cannot parse{}.ParseEventhandles the probe correctly. Affects every connector.updateWebhookexposesgenerate_new_secret/previous_secret_active_hours, so during rotation either key may be valid; both implementations use onlysecretand ignoreadditional_secret.Router-data shadow validation
Not run.
juspay/ucs-shadow-validation-servicerequires a live legacy Hyperswitch router and a live UCS grpc-server both hitting real Finix sandbox — it has no fixture or replay mode — and the Docker path is unavailable in the build environment. Separately, webhook parity is not measurable with that service as it stands:WebhookShadowSnapshotcarries nopayment_id, androuterDataHandlerrejects any payload missing it (VS_42) before comparing. A complete runbook for the cheapest viable path, including the exact[comparison_service]config and wallet rollout keys, is available and can be attached on request.🤖 Generated with Claude Code